package storage

import (
	"fmt"
	"io"
	"os"
	"path/filepath"
	"slices"
	"strings"

	"github.com/OffchainLabs/prysm/v7/beacon-chain/db/filesystem"
	"github.com/OffchainLabs/prysm/v7/beacon-chain/node"
	"github.com/OffchainLabs/prysm/v7/cmd"
	das "github.com/OffchainLabs/prysm/v7/cmd/beacon-chain/das/flags"
	"github.com/OffchainLabs/prysm/v7/config/params"
	"github.com/OffchainLabs/prysm/v7/consensus-types/primitives"
	"github.com/pkg/errors"
	log "github.com/sirupsen/logrus"
	"github.com/urfave/cli/v2"
)

var (
	BlobStoragePathFlag = &cli.PathFlag{
		Name:  "blob-path",
		Usage: "Location for blob storage. Default location will be a 'blobs' directory next to the beacon db.",
	}
	BlobStorageLayout = &cli.StringFlag{
		Name:        "blob-storage-layout",
		Usage:       layoutFlagUsage(),
		DefaultText: fmt.Sprintf("\"%s\", unless a different existing layout is detected", filesystem.LayoutNameByEpoch),
	}
	DataColumnStoragePathFlag = &cli.PathFlag{
		Name:  "data-column-path",
		Usage: "Location for data column storage. Default location will be a 'data-columns' directory next to the beacon db.",
	}
)

// Flags is the list of CLI flags for configuring blob storage.
var Flags = []cli.Flag{
	BlobStoragePathFlag,
	BlobStorageLayout,
	DataColumnStoragePathFlag,
}

func layoutOptions() string {
	return "available options are: " + strings.Join(filesystem.LayoutNames, ", ") + "."
}

func layoutFlagUsage() string {
	return "Dictates how to organize the blob directory structure on disk, " + layoutOptions()
}

func validateLayoutFlag(_ *cli.Context, v string) error {
	if slices.Contains(filesystem.LayoutNames, v) {
		return nil
	}
	return errors.Errorf("invalid value '%s' for flag --%s, %s", v, BlobStorageLayout.Name, layoutOptions())
}

// BeaconNodeOptions sets configuration values on the node.BeaconNode value at node startup.
// Note: we can't get the right context from cli.Context, because the beacon node setup code uses this context to
// create a cancellable context. If we switch to using App.RunContext, we can set up this cancellation in the cmd
// package instead, and allow the functional options to tap into context cancellation.
func BeaconNodeOptions(c *cli.Context) ([]node.Option, error) {
	blobRetentionEpoch, err := blobRetentionEpoch(c)
	if err != nil {
		return nil, errors.Wrap(err, "blob retention epoch")
	}

	blobPath := blobStoragePath(c)
	layout, err := detectLayout(blobPath, c)
	if err != nil {
		return nil, errors.Wrap(err, "detecting blob storage layout")
	}
	if layout == filesystem.LayoutNameFlat {
		log.Warnf("Existing '%s' blob storage layout detected. Consider setting the flag --%s=%s for faster startup and more reliable pruning. Setting this flag will automatically migrate your existing blob storage to the newer layout on the next restart.",

			filesystem.LayoutNameFlat, BlobStorageLayout.Name, filesystem.LayoutNameByEpoch)
	}
	blobStorageOptions := node.WithBlobStorageOptions(
		filesystem.WithBlobRetentionEpochs(blobRetentionEpoch),
		filesystem.WithBasePath(blobPath),
		filesystem.WithLayout(layout), // This is validated in the Action func for BlobStorageLayout.
	)

	dataColumnRetentionEpoch, err := dataColumnRetentionEpoch(c)
	if err != nil {
		return nil, errors.Wrap(err, "data column retention epoch")
	}

	dataColumnStorageOption := node.WithDataColumnStorageOptions(
		filesystem.WithDataColumnRetentionEpochs(dataColumnRetentionEpoch),
		filesystem.WithDataColumnBasePath(dataColumnStoragePath(c)),
	)

	opts := []node.Option{blobStorageOptions, dataColumnStorageOption}
	return opts, nil
}

// stringFlagGetter makes testing detectLayout easier
// because we don't need to mess with FlagSets and cli types.
type stringFlagGetter interface {
	String(name string) string
}

// detectLayout determines which layout to use based on explicit user flags or by probing the
// blob directory to determine the previously used layout.
// - explicit: If the user has specified a layout flag, that layout is returned.
// - flat: If directories that look like flat layout's block root paths are present.
// - by-epoch: default if neither of the above is true.
func detectLayout(dir string, c stringFlagGetter) (string, error) {
	explicit := c.String(BlobStorageLayout.Name)
	if explicit != "" {
		return explicit, nil
	}

	dir = filepath.Clean(dir)
	// nosec: this path is provided by the node operator via flag
	base, err := os.Open(dir) // #nosec G304
	if err != nil {
		// 'blobs' directory does not exist yet, so default to by-epoch.
		return filesystem.LayoutNameByEpoch, nil
	}
	defer func() {
		if err := base.Close(); err != nil {
			log.WithError(err).Errorf("Could not close blob storage directory")
		}
	}()

	// When we go looking for existing by-root directories, we only need to find one directory
	// but one of those directories could be the `by-epoch` layout's top-level directory,
	// and it seems possible that on some platforms we could get extra system directories that I don't
	// know how to anticipate (looking at you, Windows), so I picked 16 as a small number with a generous
	// amount of wiggle room to be confident that we'll likely see a by-root director if one exists.
	entries, err := base.Readdirnames(16)
	if err != nil {
		// We can get this error if the directory exists and is empty
		if errors.Is(err, io.EOF) {
			return filesystem.LayoutNameByEpoch, nil
		}
		return "", errors.Wrap(err, "reading blob storage directory")
	}
	if slices.ContainsFunc(entries, filesystem.IsBlockRootDir) {
		return filesystem.LayoutNameFlat, nil
	}
	return filesystem.LayoutNameByEpoch, nil
}

func blobStoragePath(c *cli.Context) string {
	blobsPath := c.Path(BlobStoragePathFlag.Name)
	if blobsPath == "" {
		// append a "blobs" subdir to the end of the data dir path
		blobsPath = filepath.Join(c.String(cmd.DataDirFlag.Name), "blobs")
	}
	return blobsPath
}

func dataColumnStoragePath(c *cli.Context) string {
	dataColumnsPath := c.Path(DataColumnStoragePathFlag.Name)
	if dataColumnsPath == "" {
		// append a "data-columns" subdir to the end of the data dir path
		dataColumnsPath = filepath.Join(c.String(cmd.DataDirFlag.Name), "data-columns")
	}

	return dataColumnsPath
}

var errInvalidBlobRetentionEpochs = errors.New("value is smaller than spec minimum")

// blobRetentionEpoch returns the spec default MIN_EPOCHS_FOR_BLOB_SIDECARS_REQUEST
// or a user-specified flag overriding this value. If a user-specified override is
// smaller than the spec default, an error will be returned.
func blobRetentionEpoch(cliCtx *cli.Context) (primitives.Epoch, error) {
	spec := params.BeaconConfig().MinEpochsForBlobsSidecarsRequest
	if !cliCtx.IsSet(das.BlobRetentionEpochFlag.Name) {
		return spec, nil
	}

	re := primitives.Epoch(cliCtx.Uint64(das.BlobRetentionEpochFlag.Name))
	// Validate the epoch value against the spec default.
	if re < params.BeaconConfig().MinEpochsForBlobsSidecarsRequest {
		return spec, errors.Wrapf(errInvalidBlobRetentionEpochs, "%s=%d, spec=%d", das.BlobRetentionEpochFlag.Name, re, spec)
	}

	return re, nil
}

// dataColumnRetentionEpoch returns the spec default MIN_EPOCHS_FOR_DATA_COLUMN_SIDECARS_REQUEST
// or a user-specified flag overriding this value. If a user-specified override is
// smaller than the spec default, an error will be returned.
func dataColumnRetentionEpoch(cliCtx *cli.Context) (primitives.Epoch, error) {
	defaultValue := params.BeaconConfig().MinEpochsForDataColumnSidecarsRequest
	if !cliCtx.IsSet(das.BlobRetentionEpochFlag.Name) {
		return defaultValue, nil
	}

	// We use on purpose the same retention flag for both blobs and data columns.
	customValue := primitives.Epoch(cliCtx.Uint64(das.BlobRetentionEpochFlag.Name))

	// Validate the epoch value against the spec default.
	if customValue < defaultValue {
		return defaultValue, errors.Wrapf(errInvalidBlobRetentionEpochs, "%s=%d, spec=%d", das.BlobRetentionEpochFlag.Name, customValue, defaultValue)
	}

	return customValue, nil
}

func init() {
	BlobStorageLayout.Action = validateLayoutFlag
}
